Micron Document
baubs git

Node / mirrors / respira / commits / eadbecc

Commit eadbecc401d44bb6053aa02f848f3f314798dd56


Parents : 501a7e8
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-10T14:10:27+01:00

Add thread metadata display and unique color handling

- Fix PyStitch threadlist interpretation: threads = color blocks, not unique colors
- Add uniqueColors array to PesPatternData with proper deduplication at data layer
- Display thread metadata (brand, catalog number, chart, description) across all components
- Show unique colors vs thread blocks (e.g., "5 / 12" colors/blocks)
- Improve null value handling for missing thread metadata
- Reorder metadata display: brand + catalog # • chart + description
- Add metadata to pattern preview legend, tooltips, and color swatches

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes

5 files changed, 865 insertions(+), 615 deletions(-)


Diff

diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx
index d438da1..e7b9635 100644
--- a/src/components/FileUpload.tsx
+++ b/src/components/FileUpload.tsx
@@ -126,7 +126,7 @@ export function FileUpload({
{!isLoading && pesData && (
<div className="mb-3">
- <div className="grid grid-cols-2 gap-2 text-xs mb-2">
+ <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
@@ -140,22 +140,48 @@ export function FileUpload({
{pesData.stitchCount.toLocaleString()}
</span>
</div>
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">Colors / Blocks</span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {pesData.uniqueColors.length} / {pesData.threads.length}
+ </span>
+ </div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
- {pesData.threads.slice(0, 8).map((thread, idx) => (
- <div
- key={idx}
- className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
- style={{ backgroundColor: thread.hex }}
- title={`Thread ${idx + 1}: ${thread.hex}`}
- />
- ))}
- {pesData.colorCount > 8 && (
+ {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber ? `#${color.catalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ color.chart,
+ color.description
+ ].filter(Boolean).join(" ");
+
+ const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+
+ const tooltipText = metadata
+ ? `Color ${idx + 1}: ${color.hex} - ${metadata}`
+ : `Color ${idx + 1}: ${color.hex}`;
+
+ return (
+ <div
+ key={idx}
+ className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
+ style={{ backgroundColor: color.hex }}
+ title={tooltipText}
+ />
+ );
+ })}
+ {pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
- +{pesData.colorCount - 8}
+ +{pesData.uniqueColors.length - 8}
</div>
)}
</div>

diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx
index 91a559b..8782652 100644
--- a/src/components/PatternCanvas.tsx
+++ b/src/components/PatternCanvas.tsx
@@ -296,17 +296,42 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
{pesData && (
<>
{/* Thread Legend Overlay */}
- <div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[150px]">
- <h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4>
- {pesData.threads.map((thread, index) => (
- <div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
- <div
- className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0"
- style={{ backgroundColor: thread.hex }}
- />
- <span className="text-[11px] text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
- </div>
- ))}
+ <div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[200px]">
+ <h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Colors</h4>
+ {pesData.uniqueColors.map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber ? `#${color.catalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ color.chart,
+ color.description
+ ].filter(Boolean).join(" ");
+
+ return (
+ <div key={idx} className="flex items-start gap-2 mb-1.5 last:mb-0">
+ <div
+ className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
+ style={{ backgroundColor: color.hex }}
+ />
+ <div className="flex-1 min-w-0">
+ <div className="text-[11px] font-semibold text-gray-900 dark:text-gray-100">
+ Color {idx + 1}
+ </div>
+ {(primaryMetadata || secondaryMetadata) && (
+ <div className="text-[9px] text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
+ {primaryMetadata}
+ {primaryMetadata && secondaryMetadata && <span className="mx-1">•</span>}
+ {secondaryMetadata}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
</div>
{/* Pattern Offset Indicator */}

diff --git a/src/components/PatternSummaryCard.tsx b/src/components/PatternSummaryCard.tsx
index 40eca43..aa3084d 100644
--- a/src/components/PatternSummaryCard.tsx
+++ b/src/components/PatternSummaryCard.tsx
@@ -28,7 +28,7 @@ export function PatternSummaryCard({
</div>
</div>
- <div className="grid grid-cols-2 gap-2 text-xs mb-3">
+ <div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
@@ -42,22 +42,50 @@ export function PatternSummaryCard({
{pesData.stitchCount.toLocaleString()}
</span>
</div>
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">Colors</span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {pesData.uniqueColors.length}
+ </span>
+ </div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
- {pesData.threads.slice(0, 8).map((thread, idx) => (
- <div
- key={idx}
- className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
- style={{ backgroundColor: thread.hex }}
- title={`Thread ${idx + 1}: ${thread.hex}`}
- />
- ))}
- {pesData.colorCount > 8 && (
+ {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber ? `#${color.catalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ color.chart,
+ color.description
+ ].filter(Boolean).join(" ");
+
+ const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+
+ // Show which thread blocks use this color
+ const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
+ const tooltipText = metadata
+ ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
+ : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
+
+ return (
+ <div
+ key={idx}
+ className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
+ style={{ backgroundColor: color.hex }}
+ title={tooltipText}
+ />
+ );
+ })}
+ {pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
- +{pesData.colorCount - 8}
+ +{pesData.uniqueColors.length - 8}
</div>
)}
</div>

diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx
index 215fa82..29ec2e8 100644
--- a/src/components/ProgressMonitor.tsx
+++ b/src/components/ProgressMonitor.tsx
@@ -1,316 +1,412 @@
-import {
- CheckCircleIcon,
- ArrowRightIcon,
- CircleStackIcon,
- PlayIcon,
- CheckBadgeIcon,
- ClockIcon,
- PauseCircleIcon,
- ExclamationCircleIcon,
- ChartBarIcon,
- ArrowPathIcon
-} from '@heroicons/react/24/solid';
-import type { PatternInfo, SewingProgress } from '../types/machine';
-import { MachineStatus } from '../types/machine';
-import type { PesPatternData } from '../utils/pystitchConverter';
-import {
- canStartSewing,
- canStartMaskTrace,
- canResumeSewing,
- getStateVisualInfo
-} from '../utils/machineStateHelpers';
-
-interface ProgressMonitorProps {
- machineStatus: MachineStatus;
- patternInfo: PatternInfo | null;
- sewingProgress: SewingProgress | null;
- pesData: PesPatternData | null;
- onStartMaskTrace: () => void;
- onStartSewing: () => void;
- onResumeSewing: () => void;
- onDeletePattern: () => void;
- isDeleting?: boolean;
-}
-
-export function ProgressMonitor({
- machineStatus,
- patternInfo,
- sewingProgress,
- pesData,
- onStartMaskTrace,
- onStartSewing,
- onResumeSewing,
- isDeleting = false,
-}: ProgressMonitorProps) {
- // State indicators
- const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
-
- const stateVisual = getStateVisualInfo(machineStatus);
-
- const progressPercent = patternInfo
- ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
- : 0;
-
- // Calculate color block information from pesData
- const colorBlocks = pesData ? (() => {
- const blocks: Array<{
- colorIndex: number;
- threadHex: string;
- startStitch: number;
- endStitch: number;
- stitchCount: number;
- }> = [];
-
- let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
- let blockStartStitch = 0;
-
- for (let i = 0; i < pesData.stitches.length; i++) {
- const stitchColorIndex = pesData.stitches[i][3];
-
- // When color changes, save the previous block
- if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) {
- const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
- blocks.push({
- colorIndex: currentColorIndex,
- threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
- startStitch: blockStartStitch,
- endStitch: endStitch,
- stitchCount: endStitch - blockStartStitch,
- });
-
- currentColorIndex = stitchColorIndex;
- blockStartStitch = i;
- }
- }
-
- return blocks;
- })() : [];
-
- // Determine current color block based on current stitch
- const currentStitch = sewingProgress?.currentStitch || 0;
- const currentBlockIndex = colorBlocks.findIndex(
- block => currentStitch >= block.startStitch && currentStitch < block.endStitch
- );
-
- const stateIndicatorColors = {
- idle: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
- info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
- active: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- waiting: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
- complete: 'bg-green-50 dark:bg-green-900/20 border-green-600',
- success: 'bg-green-50 dark:bg-green-900/20 border-green-600',
- interrupted: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- error: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- danger: 'bg-red-50 dark:bg-red-900/20 border-red-600',
- };
-
- return (
- <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
- <div className="flex items-start gap-3 mb-3">
- <ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
- <div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Sewing Progress</h3>
- {sewingProgress && (
- <p className="text-xs text-gray-600 dark:text-gray-400">
- {progressPercent.toFixed(1)}% complete
- </p>
- )}
- </div>
- </div>
-
- {/* Pattern Info */}
- {patternInfo && (
- <div className="grid grid-cols-3 gap-2 text-xs mb-3">
- <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.totalStitches.toLocaleString()}</span>
- </div>
- <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Est. Time</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {Math.floor(patternInfo.totalTime / 60)}:{String(patternInfo.totalTime % 60).padStart(2, '0')}
- </span>
- </div>
- <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Speed</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.speed} spm</span>
- </div>
- </div>
- )}
-
- {/* Progress Bar */}
- {sewingProgress && (
- <div className="mb-3">
- <div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
- <div className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
- </div>
-
- <div className="grid grid-cols-2 gap-2 text-xs mb-3">
- <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Current Stitch</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {sewingProgress.currentStitch.toLocaleString()} / {patternInfo?.totalStitches.toLocaleString() || 0}
- </span>
- </div>
- <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Time Elapsed</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {Math.floor(sewingProgress.currentTime / 60)}:{String(sewingProgress.currentTime % 60).padStart(2, '0')}
- </span>
- </div>
- </div>
- </div>
- )}
-
- {/* State Visual Indicator */}
- {patternInfo && (() => {
- const iconMap = {
- ready: <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />,
- active: <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
- waiting: <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
- complete: <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />,
- interrupted: <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />,
- error: <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
- };
-
- return (
- <div className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}>
- <div className="flex-shrink-0">
- {iconMap[stateVisual.iconName]}
- </div>
- <div className="flex-1">
- <div className="font-semibold text-xs dark:text-gray-100">{stateVisual.label}</div>
- <div className="text-[10px] text-gray-600 dark:text-gray-400">{stateVisual.description}</div>
- </div>
- </div>
- );
- })()}
-
- {/* Color Blocks */}
- {colorBlocks.length > 0 && (
- <div className="mb-3">
- <h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">Color Blocks</h4>
- <div className="flex flex-col gap-2">
- {colorBlocks.map((block, index) => {
- const isCompleted = currentStitch >= block.endStitch;
- const isCurrent = index === currentBlockIndex;
-
- // Calculate progress within current block
- let blockProgress = 0;
- if (isCurrent) {
- blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100;
- } else if (isCompleted) {
- blockProgress = 100;
- }
-
- return (
- <div
- key={index}
- className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
- isCompleted
- ? 'border-green-600 bg-green-50 dark:bg-green-900/20'
- : isCurrent
- ? 'border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow'
- : 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70'
- }`}
- role="listitem"
- aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? 'completed' : isCurrent ? 'in progress' : 'pending'}`}
- >
- <div className="flex items-center gap-2.5">
- {/* Color swatch */}
- <div
- className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
- style={{
- backgroundColor: block.threadHex,
- ...(isCurrent && { borderColor: '#9333ea' })
- }}
- title={`Thread color: ${block.threadHex}`}
- aria-label={`Thread color ${block.threadHex}`}
- />
-
- {/* Thread info */}
- <div className="flex-1 min-w-0">
- <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
- Thread {block.colorIndex + 1}
- </div>
- <div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
- {block.stitchCount.toLocaleString()} stitches
- </div>
- </div>
-
- {/* Status icon */}
- {isCompleted ? (
- <CheckCircleIcon className="w-5 h-5 text-green-600 flex-shrink-0" aria-label="Completed" />
- ) : isCurrent ? (
- <ArrowRightIcon className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
- ) : (
- <CircleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-label="Pending" />
- )}
- </div>
-
- {/* Progress bar for current block */}
- {isCurrent && (
- <div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
- <div
- className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
- style={{ width: `${blockProgress}%` }}
- role="progressbar"
- aria-valuenow={Math.round(blockProgress)}
- aria-valuemin={0}
- aria-valuemax={100}
- aria-label={`${Math.round(blockProgress)}% complete`}
- />
- </div>
- )}
- </div>
- );
- })}
- </div>
- </div>
- )}
-
- {/* Action buttons */}
- <div className="flex gap-2">
- {/* Resume has highest priority when available */}
- {canResumeSewing(machineStatus) && (
- <button
- onClick={onResumeSewing}
- disabled={isDeleting}
- className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
- aria-label="Resume sewing the current pattern"
- >
- <PlayIcon className="w-3.5 h-3.5" />
- Resume Sewing
- </button>
- )}
-
- {/* Start Sewing - primary action, takes more space */}
- {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
- <button
- onClick={onStartSewing}
- disabled={isDeleting}
- className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
- aria-label="Start sewing the pattern"
- >
- <PlayIcon className="w-3.5 h-3.5" />
- Start Sewing
- </button>
- )}
-
- {/* Start Mask Trace - secondary action */}
- {canStartMaskTrace(machineStatus) && (
- <button
- onClick={onStartMaskTrace}
- disabled={isDeleting}
- className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
- aria-label={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
- >
- <ArrowPathIcon className="w-3.5 h-3.5" />
- {isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'}
- </button>
- )}
- </div>
- </div>
- );
-}
+import {
+ CheckCircleIcon,
+ ArrowRightIcon,
+ CircleStackIcon,
+ PlayIcon,
+ CheckBadgeIcon,
+ ClockIcon,
+ PauseCircleIcon,
+ ExclamationCircleIcon,
+ ChartBarIcon,
+ ArrowPathIcon,
+} from "@heroicons/react/24/solid";
+import type { PatternInfo, SewingProgress } from "../types/machine";
+import { MachineStatus } from "../types/machine";
+import type { PesPatternData } from "../utils/pystitchConverter";
+import {
+ canStartSewing,
+ canStartMaskTrace,
+ canResumeSewing,
+ getStateVisualInfo,
+} from "../utils/machineStateHelpers";
+
+interface ProgressMonitorProps {
+ machineStatus: MachineStatus;
+ patternInfo: PatternInfo | null;
+ sewingProgress: SewingProgress | null;
+ pesData: PesPatternData | null;
+ onStartMaskTrace: () => void;
+ onStartSewing: () => void;
+ onResumeSewing: () => void;
+ onDeletePattern: () => void;
+ isDeleting?: boolean;
+}
+
+export function ProgressMonitor({
+ machineStatus,
+ patternInfo,
+ sewingProgress,
+ pesData,
+ onStartMaskTrace,
+ onStartSewing,
+ onResumeSewing,
+ isDeleting = false,
+}: ProgressMonitorProps) {
+ // State indicators
+ const isMaskTraceComplete =
+ machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
+
+ const stateVisual = getStateVisualInfo(machineStatus);
+
+ const progressPercent = patternInfo
+ ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
+ : 0;
+
+ // Calculate color block information from pesData
+ const colorBlocks = pesData
+ ? (() => {
+ const blocks: Array<{
+ colorIndex: number;
+ threadHex: string;
+ startStitch: number;
+ endStitch: number;
+ stitchCount: number;
+ threadCatalogNumber: string | null;
+ threadBrand: string | null;
+ threadDescription: string | null;
+ threadChart: string | null;
+ }> = [];
+
+ let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
+ let blockStartStitch = 0;
+
+ for (let i = 0; i < pesData.stitches.length; i++) {
+ const stitchColorIndex = pesData.stitches[i][3];
+
+ // When color changes, save the previous block
+ if (
+ stitchColorIndex !== currentColorIndex ||
+ i === pesData.stitches.length - 1
+ ) {
+ const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
+ const thread = pesData.threads[currentColorIndex];
+ blocks.push({
+ colorIndex: currentColorIndex,
+ threadHex: thread?.hex || "#000000",
+ threadCatalogNumber: thread?.catalogNumber ?? null,
+ threadBrand: thread?.brand ?? null,
+ threadDescription: thread?.description ?? null,
+ threadChart: thread?.chart ?? null,
+ startStitch: blockStartStitch,
+ endStitch: endStitch,
+ stitchCount: endStitch - blockStartStitch,
+ });
+
+ currentColorIndex = stitchColorIndex;
+ blockStartStitch = i;
+ }
+ }
+
+ return blocks;
+ })()
+ : [];
+
+ // Determine current color block based on current stitch
+ const currentStitch = sewingProgress?.currentStitch || 0;
+ const currentBlockIndex = colorBlocks.findIndex(
+ (block) =>
+ currentStitch >= block.startStitch && currentStitch < block.endStitch,
+ );
+
+ const stateIndicatorColors = {
+ idle: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
+ info: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
+ active: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ waiting: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
+ complete: "bg-green-50 dark:bg-green-900/20 border-green-600",
+ success: "bg-green-50 dark:bg-green-900/20 border-green-600",
+ interrupted: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ error: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ danger: "bg-red-50 dark:bg-red-900/20 border-red-600",
+ };
+
+ return (
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
+ <div className="flex items-start gap-3 mb-3">
+ <ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Sewing Progress
+ </h3>
+ {sewingProgress && (
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ {progressPercent.toFixed(1)}% complete
+ </p>
+ )}
+ </div>
+ </div>
+
+ {/* Pattern Info */}
+ {patternInfo && (
+ <div className="grid grid-cols-3 gap-2 text-xs mb-3">
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Total Stitches
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {patternInfo.totalStitches.toLocaleString()}
+ </span>
+ </div>
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Est. Time
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {Math.floor(patternInfo.totalTime / 60)}:
+ {String(patternInfo.totalTime % 60).padStart(2, "0")}
+ </span>
+ </div>
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Speed
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {patternInfo.speed} spm
+ </span>
+ </div>
+ </div>
+ )}
+
+ {/* Progress Bar */}
+ {sewingProgress && (
+ <div className="mb-3">
+ <div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
+ <div
+ className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
+ style={{ width: `${progressPercent}%` }}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-2 text-xs mb-3">
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Current Stitch
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {sewingProgress.currentStitch.toLocaleString()} /{" "}
+ {patternInfo?.totalStitches.toLocaleString() || 0}
+ </span>
+ </div>
+ <div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Time Elapsed
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {Math.floor(sewingProgress.currentTime / 60)}:
+ {String(sewingProgress.currentTime % 60).padStart(2, "0")}
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* State Visual Indicator */}
+ {patternInfo &&
+ (() => {
+ const iconMap = {
+ ready: (
+ <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
+ ),
+ active: (
+ <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
+ ),
+ waiting: (
+ <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
+ ),
+ complete: (
+ <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
+ ),
+ interrupted: (
+ <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
+ ),
+ error: (
+ <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
+ ),
+ };
+
+ return (
+ <div
+ className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
+ >
+ <div className="flex-shrink-0">
+ {iconMap[stateVisual.iconName]}
+ </div>
+ <div className="flex-1">
+ <div className="font-semibold text-xs dark:text-gray-100">
+ {stateVisual.label}
+ </div>
+ <div className="text-[10px] text-gray-600 dark:text-gray-400">
+ {stateVisual.description}
+ </div>
+ </div>
+ </div>
+ );
+ })()}
+
+ {/* Color Blocks */}
+ {colorBlocks.length > 0 && (
+ <div className="mb-3">
+ <h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">
+ Color Blocks
+ </h4>
+ <div className="flex flex-col gap-2">
+ {colorBlocks.map((block, index) => {
+ const isCompleted = currentStitch >= block.endStitch;
+ const isCurrent = index === currentBlockIndex;
+
+ // Calculate progress within current block
+ let blockProgress = 0;
+ if (isCurrent) {
+ blockProgress =
+ ((currentStitch - block.startStitch) / block.stitchCount) *
+ 100;
+ } else if (isCompleted) {
+ blockProgress = 100;
+ }
+
+ return (
+ <div
+ key={index}
+ className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
+ isCompleted
+ ? "border-green-600 bg-green-50 dark:bg-green-900/20"
+ : isCurrent
+ ? "border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow"
+ : "border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70"
+ }`}
+ role="listitem"
+ aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
+ >
+ <div className="flex items-center gap-2.5">
+ {/* Color swatch */}
+ <div
+ className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
+ style={{
+ backgroundColor: block.threadHex,
+ ...(isCurrent && { borderColor: "#9333ea" }),
+ }}
+ title={`Thread color: ${block.threadHex}`}
+ aria-label={`Thread color ${block.threadHex}`}
+ />
+
+ {/* Thread info */}
+ <div className="flex-1 min-w-0">
+ <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
+ Thread {block.colorIndex + 1}
+ {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
+ <span className="font-normal text-gray-600 dark:text-gray-400">
+ {" "}
+ (
+ {(() => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ block.threadBrand,
+ block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
+ ].filter(Boolean).join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ block.threadChart,
+ block.threadDescription
+ ].filter(Boolean).join(" ");
+
+ return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+ })()}
+ )
+ </span>
+ )}
+ </div>
+ <div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
+ {block.stitchCount.toLocaleString()} stitches
+ </div>
+ </div>
+
+ {/* Status icon */}
+ {isCompleted ? (
+ <CheckCircleIcon
+ className="w-5 h-5 text-green-600 flex-shrink-0"
+ aria-label="Completed"
+ />
+ ) : isCurrent ? (
+ <ArrowRightIcon
+ className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse"
+ aria-label="In progress"
+ />
+ ) : (
+ <CircleStackIcon
+ className="w-5 h-5 text-gray-400 flex-shrink-0"
+ aria-label="Pending"
+ />
+ )}
+ </div>
+
+ {/* Progress bar for current block */}
+ {isCurrent && (
+ <div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
+ style={{ width: `${blockProgress}%` }}
+ role="progressbar"
+ aria-valuenow={Math.round(blockProgress)}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ aria-label={`${Math.round(blockProgress)}% complete`}
+ />
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* Action buttons */}
+ <div className="flex gap-2">
+ {/* Resume has highest priority when available */}
+ {canResumeSewing(machineStatus) && (
+ <button
+ onClick={onResumeSewing}
+ disabled={isDeleting}
+ className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label="Resume sewing the current pattern"
+ >
+ <PlayIcon className="w-3.5 h-3.5" />
+ Resume Sewing
+ </button>
+ )}
+
+ {/* Start Sewing - primary action, takes more space */}
+ {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
+ <button
+ onClick={onStartSewing}
+ disabled={isDeleting}
+ className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label="Start sewing the pattern"
+ >
+ <PlayIcon className="w-3.5 h-3.5" />
+ Start Sewing
+ </button>
+ )}
+
+ {/* Start Mask Trace - secondary action */}
+ {canStartMaskTrace(machineStatus) && (
+ <button
+ onClick={onStartMaskTrace}
+ disabled={isDeleting}
+ className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label={
+ isMaskTraceComplete
+ ? "Start mask trace again"
+ : "Start mask trace"
+ }
+ >
+ <ArrowPathIcon className="w-3.5 h-3.5" />
+ {isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}

diff --git a/src/utils/pystitchConverter.ts b/src/utils/pystitchConverter.ts
index a5d8996..3b28fd1 100644
--- a/src/utils/pystitchConverter.ts
+++ b/src/utils/pystitchConverter.ts
@@ -1,266 +1,341 @@
-import { pyodideLoader } from './pyodideLoader';
-import {
- STITCH,
- MOVE,
- TRIM,
- END,
- PEN_FEED_DATA,
- PEN_CUT_DATA,
- PEN_COLOR_END,
- PEN_DATA_END,
-} from './embroideryConstants';
-
-// JavaScript constants module to expose to Python
-const jsEmbConstants = {
- STITCH,
- MOVE,
- TRIM,
- END,
-};
-
-export interface PesPatternData {
- stitches: number[][];
- threads: Array<{
- color: number;
- hex: string;
- }>;
- penData: Uint8Array;
- colorCount: number;
- stitchCount: number;
- bounds: {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- };
-}
-
-/**
- * Reads a PES file using PyStitch and converts it to PEN format
- */
-export async function convertPesToPen(file: File): Promise<PesPatternData> {
- // Ensure Pyodide is initialized
- const pyodide = await pyodideLoader.initialize();
-
- // Register our JavaScript constants module for Python to import
- pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
-
- // Read the PES file
- const buffer = await file.arrayBuffer();
- const uint8Array = new Uint8Array(buffer);
-
- // Write file to Pyodide virtual filesystem
- const filename = '/tmp/pattern.pes';
- pyodide.FS.writeFile(filename, uint8Array);
-
- // Read the pattern using PyStitch
- const result = await pyodide.runPythonAsync(`
-import pystitch
-from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
-from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
-
-# Read the PES file
-pattern = pystitch.read('${filename}')
-
-def map_cmd(pystitch_cmd):
- """Map PyStitch command to our JavaScript constant values
-
- This ensures we have known, consistent values regardless of PyStitch's internal values.
- Our JS constants use pyembroidery-style bitmask values:
- STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
- """
- if pystitch_cmd == STITCH:
- return JS_STITCH
- elif pystitch_cmd == JUMP:
- return JS_MOVE # PyStitch JUMP maps to our MOVE constant
- elif pystitch_cmd == TRIM:
- return JS_TRIM
- elif pystitch_cmd == END:
- return JS_END
- else:
- # For any other commands, preserve as bitmask
- result = JS_STITCH
- if pystitch_cmd & JUMP:
- result |= JS_MOVE
- if pystitch_cmd & TRIM:
- result |= JS_TRIM
- if pystitch_cmd & END:
- result |= JS_END
- return result
-
-# Use the raw stitches list which preserves command flags
-# Each stitch in pattern.stitches is [x, y, cmd]
-# We need to assign color indices based on COLOR_CHANGE commands
-# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
-
-stitches_with_colors = []
-current_color = 0
-
-for i, stitch in enumerate(pattern.stitches):
- x, y, cmd = stitch
-
- # Check for color change command - increment color but don't add stitch
- if cmd == COLOR_CHANGE:
- current_color += 1
- continue
-
- # Check for stop command - skip it
- if cmd == STOP:
- continue
-
- # Check for standalone END command (no stitch data)
- if cmd == END:
- continue
-
- # Add actual stitch with color index and mapped command
- # Map PyStitch cmd values to our known JavaScript constant values
- mapped_cmd = map_cmd(cmd)
- stitches_with_colors.append([x, y, mapped_cmd, current_color])
-
-# Convert to JSON-serializable format
-{
- 'stitches': stitches_with_colors,
- 'threads': [
- {
- 'color': thread.color if hasattr(thread, 'color') else 0,
- 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000'
- }
- for thread in pattern.threadlist
- ],
- 'thread_count': len(pattern.threadlist),
- 'stitch_count': len(stitches_with_colors),
- 'color_changes': current_color
-}
- `);
-
- // Convert Python result to JavaScript
- const data = result.toJs({ dict_converter: Object.fromEntries });
-
-
- // Clean up virtual file
- try {
- pyodide.FS.unlink(filename);
- } catch {
- // Ignore errors
- }
-
- // Extract stitches and validate
- const stitches: number[][] = Array.from(data.stitches as ArrayLike<ArrayLike<number>>).map((stitch) =>
- Array.from(stitch)
- );
-
- if (!stitches || stitches.length === 0) {
- throw new Error('Invalid PES file or no stitches found');
- }
-
- // Extract thread data
- const threads = (data.threads as Array<{ color?: number; hex?: string }>).map((thread) => ({
- color: thread.color || 0,
- hex: thread.hex || '#000000',
- }));
-
- // Track bounds
- let minX = Infinity;
- let maxX = -Infinity;
- let minY = Infinity;
- let maxY = -Infinity;
-
- // PyStitch returns ABSOLUTE coordinates
- // PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
- const penStitches: number[] = [];
-
- for (let i = 0; i < stitches.length; i++) {
- const stitch = stitches[i];
- const absX = Math.round(stitch[0]);
- const absY = Math.round(stitch[1]);
- const cmd = stitch[2];
- const stitchColor = stitch[3]; // Color index from PyStitch
-
- // Track bounds for non-jump stitches
- if (cmd === STITCH) {
- minX = Math.min(minX, absX);
- maxX = Math.max(maxX, absX);
- minY = Math.min(minY, absY);
- maxY = Math.max(maxY, absY);
- }
-
- // Encode absolute coordinates with flags in low 3 bits
- // Shift coordinates left by 3 bits to make room for flags
- // As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
- let xEncoded = (absX << 3) & 0xFFFF;
- let yEncoded = (absY << 3) & 0xFFFF;
-
- // Add command flags to Y-coordinate based on stitch type
- if (cmd & MOVE) {
- // MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
- yEncoded |= PEN_FEED_DATA;
- }
- if (cmd & TRIM) {
- // TRIM: Set bit 1 (CUT_DATA) - cut thread command
- yEncoded |= PEN_CUT_DATA;
- }
-
- // Check if this is the last stitch
- const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
-
- // Check for color change by comparing stitch color index
- // Mark the LAST stitch of the previous color with PEN_COLOR_END
- // BUT: if this is the last stitch of the entire pattern, use DATA_END instead
- const nextStitch = stitches[i + 1];
- const nextStitchColor = nextStitch?.[3];
-
- if (!isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor) {
- // This is the last stitch before a color change (but not the last stitch overall)
- xEncoded = (xEncoded & 0xFFF8) | PEN_COLOR_END;
- } else if (isLastStitch) {
- // This is the very last stitch of the pattern
- xEncoded = (xEncoded & 0xFFF8) | PEN_DATA_END;
- }
-
- // Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
- penStitches.push(
- xEncoded & 0xFF,
- (xEncoded >> 8) & 0xFF,
- yEncoded & 0xFF,
- (yEncoded >> 8) & 0xFF
- );
-
- // Check for end command
- if ((cmd & END) !== 0) {
- break;
- }
- }
-
- const penData = new Uint8Array(penStitches);
-
- return {
- stitches,
- threads,
- penData,
- colorCount: data.thread_count,
- stitchCount: data.stitch_count,
- bounds: {
- minX: minX === Infinity ? 0 : minX,
- maxX: maxX === -Infinity ? 0 : maxX,
- minY: minY === Infinity ? 0 : minY,
- maxY: maxY === -Infinity ? 0 : maxY,
- },
- };
-}
-
-/**
- * Get thread color from pattern data
- */
-export function getThreadColor(data: PesPatternData, colorIndex: number): string {
- if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
- // Default colors if not specified or index out of bounds
- const defaultColors = [
- '#FF0000', '#00FF00', '#0000FF', '#FFFF00',
- '#FF00FF', '#00FFFF', '#FFA500', '#800080',
- ];
- const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
- return defaultColors[safeIndex];
- }
-
- return data.threads[colorIndex]?.hex || '#000000';
-}
+import { pyodideLoader } from "./pyodideLoader";
+import {
+ STITCH,
+ MOVE,
+ TRIM,
+ END,
+ PEN_FEED_DATA,
+ PEN_CUT_DATA,
+ PEN_COLOR_END,
+ PEN_DATA_END,
+} from "./embroideryConstants";
+
+// JavaScript constants module to expose to Python
+const jsEmbConstants = {
+ STITCH,
+ MOVE,
+ TRIM,
+ END,
+};
+
+export interface PesPatternData {
+ stitches: number[][];
+ threads: Array<{
+ color: number;
+ hex: string;
+ brand: string | null;
+ catalogNumber: string | null;
+ description: string | null;
+ chart: string | null;
+ }>;
+ uniqueColors: Array<{
+ color: number;
+ hex: string;
+ brand: string | null;
+ catalogNumber: string | null;
+ description: string | null;
+ chart: string | null;
+ threadIndices: number[]; // Which thread entries use this color
+ }>;
+ penData: Uint8Array;
+ colorCount: number;
+ stitchCount: number;
+ bounds: {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ };
+}
+
+/**
+ * Reads a PES file using PyStitch and converts it to PEN format
+ */
+export async function convertPesToPen(file: File): Promise<PesPatternData> {
+ // Ensure Pyodide is initialized
+ const pyodide = await pyodideLoader.initialize();
+
+ // Register our JavaScript constants module for Python to import
+ pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
+
+ // Read the PES file
+ const buffer = await file.arrayBuffer();
+ const uint8Array = new Uint8Array(buffer);
+
+ // Write file to Pyodide virtual filesystem
+ const filename = "/tmp/pattern.pes";
+ pyodide.FS.writeFile(filename, uint8Array);
+
+ // Read the pattern using PyStitch
+ const result = await pyodide.runPythonAsync(`
+import pystitch
+from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
+from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
+
+# Read the PES file
+pattern = pystitch.read('${filename}')
+
+def map_cmd(pystitch_cmd):
+ """Map PyStitch command to our JavaScript constant values
+
+ This ensures we have known, consistent values regardless of PyStitch's internal values.
+ Our JS constants use pyembroidery-style bitmask values:
+ STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
+ """
+ if pystitch_cmd == STITCH:
+ return JS_STITCH
+ elif pystitch_cmd == JUMP:
+ return JS_MOVE # PyStitch JUMP maps to our MOVE constant
+ elif pystitch_cmd == TRIM:
+ return JS_TRIM
+ elif pystitch_cmd == END:
+ return JS_END
+ else:
+ # For any other commands, preserve as bitmask
+ result = JS_STITCH
+ if pystitch_cmd & JUMP:
+ result |= JS_MOVE
+ if pystitch_cmd & TRIM:
+ result |= JS_TRIM
+ if pystitch_cmd & END:
+ result |= JS_END
+ return result
+
+# Use the raw stitches list which preserves command flags
+# Each stitch in pattern.stitches is [x, y, cmd]
+# We need to assign color indices based on COLOR_CHANGE commands
+# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
+
+stitches_with_colors = []
+current_color = 0
+
+for i, stitch in enumerate(pattern.stitches):
+ x, y, cmd = stitch
+
+ # Check for color change command - increment color but don't add stitch
+ if cmd == COLOR_CHANGE:
+ current_color += 1
+ continue
+
+ # Check for stop command - skip it
+ if cmd == STOP:
+ continue
+
+ # Check for standalone END command (no stitch data)
+ if cmd == END:
+ continue
+
+ # Add actual stitch with color index and mapped command
+ # Map PyStitch cmd values to our known JavaScript constant values
+ mapped_cmd = map_cmd(cmd)
+ stitches_with_colors.append([x, y, mapped_cmd, current_color])
+
+# Convert to JSON-serializable format
+{
+ 'stitches': stitches_with_colors,
+ 'threads': [
+ {
+ 'color': thread.color if hasattr(thread, 'color') else 0,
+ 'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
+ 'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
+ 'brand': thread.brand if hasattr(thread, 'brand') else "",
+ 'description': thread.description if hasattr(thread, 'description') else "",
+ 'chart': thread.chart if hasattr(thread, 'chart') else ""
+ }
+ for thread in pattern.threadlist
+ ],
+ 'thread_count': len(pattern.threadlist),
+ 'stitch_count': len(stitches_with_colors),
+ 'color_changes': current_color
+}
+ `);
+
+ // Convert Python result to JavaScript
+ const data = result.toJs({ dict_converter: Object.fromEntries });
+
+ // Clean up virtual file
+ try {
+ pyodide.FS.unlink(filename);
+ } catch {
+ // Ignore errors
+ }
+
+ // Extract stitches and validate
+ const stitches: number[][] = Array.from(
+ data.stitches as ArrayLike<ArrayLike<number>>,
+ ).map((stitch) => Array.from(stitch));
+
+ if (!stitches || stitches.length === 0) {
+ throw new Error("Invalid PES file or no stitches found");
+ }
+
+ // Extract thread data - preserve null values for unavailable metadata
+ const threads = (
+ data.threads as Array<{
+ color?: number;
+ hex?: string;
+ catalog_number?: number | string;
+ brand?: string;
+ description?: string;
+ chart?: string;
+ }>
+ ).map((thread) => {
+ // Normalize catalog_number - can be string or number from PyStitch
+ const catalogNum = thread.catalog_number;
+ const normalizedCatalog =
+ catalogNum !== undefined &&
+ catalogNum !== null &&
+ catalogNum !== -1 &&
+ catalogNum !== "-1" &&
+ catalogNum !== ""
+ ? String(catalogNum)
+ : null;
+
+ return {
+ color: thread.color ?? 0,
+ hex: thread.hex || "#000000",
+ catalogNumber: normalizedCatalog,
+ brand: thread.brand && thread.brand !== "" ? thread.brand : null,
+ description: thread.description && thread.description !== "" ? thread.description : null,
+ chart: thread.chart && thread.chart !== "" ? thread.chart : null,
+ };
+ });
+
+ // Track bounds
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+
+ // PyStitch returns ABSOLUTE coordinates
+ // PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
+ const penStitches: number[] = [];
+
+ for (let i = 0; i < stitches.length; i++) {
+ const stitch = stitches[i];
+ const absX = Math.round(stitch[0]);
+ const absY = Math.round(stitch[1]);
+ const cmd = stitch[2];
+ const stitchColor = stitch[3]; // Color index from PyStitch
+
+ // Track bounds for non-jump stitches
+ if (cmd === STITCH) {
+ minX = Math.min(minX, absX);
+ maxX = Math.max(maxX, absX);
+ minY = Math.min(minY, absY);
+ maxY = Math.max(maxY, absY);
+ }
+
+ // Encode absolute coordinates with flags in low 3 bits
+ // Shift coordinates left by 3 bits to make room for flags
+ // As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
+ let xEncoded = (absX << 3) & 0xffff;
+ let yEncoded = (absY << 3) & 0xffff;
+
+ // Add command flags to Y-coordinate based on stitch type
+ if (cmd & MOVE) {
+ // MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
+ yEncoded |= PEN_FEED_DATA;
+ }
+ if (cmd & TRIM) {
+ // TRIM: Set bit 1 (CUT_DATA) - cut thread command
+ yEncoded |= PEN_CUT_DATA;
+ }
+
+ // Check if this is the last stitch
+ const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
+
+ // Check for color change by comparing stitch color index
+ // Mark the LAST stitch of the previous color with PEN_COLOR_END
+ // BUT: if this is the last stitch of the entire pattern, use DATA_END instead
+ const nextStitch = stitches[i + 1];
+ const nextStitchColor = nextStitch?.[3];
+
+ if (
+ !isLastStitch &&
+ nextStitchColor !== undefined &&
+ nextStitchColor !== stitchColor
+ ) {
+ // This is the last stitch before a color change (but not the last stitch overall)
+ xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
+ } else if (isLastStitch) {
+ // This is the very last stitch of the pattern
+ xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
+ }
+
+ // Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
+ penStitches.push(
+ xEncoded & 0xff,
+ (xEncoded >> 8) & 0xff,
+ yEncoded & 0xff,
+ (yEncoded >> 8) & 0xff,
+ );
+
+ // Check for end command
+ if ((cmd & END) !== 0) {
+ break;
+ }
+ }
+
+ const penData = new Uint8Array(penStitches);
+
+ // Calculate unique colors from threads (threads represent color blocks, not unique colors)
+ const uniqueColors = threads.reduce((acc, thread, idx) => {
+ const existing = acc.find(c => c.hex === thread.hex);
+ if (existing) {
+ existing.threadIndices.push(idx);
+ } else {
+ acc.push({
+ color: thread.color,
+ hex: thread.hex,
+ brand: thread.brand,
+ catalogNumber: thread.catalogNumber,
+ description: thread.description,
+ chart: thread.chart,
+ threadIndices: [idx],
+ });
+ }
+ return acc;
+ }, [] as PesPatternData['uniqueColors']);
+
+ return {
+ stitches,
+ threads,
+ uniqueColors,
+ penData,
+ colorCount: data.thread_count,
+ stitchCount: data.stitch_count,
+ bounds: {
+ minX: minX === Infinity ? 0 : minX,
+ maxX: maxX === -Infinity ? 0 : maxX,
+ minY: minY === Infinity ? 0 : minY,
+ maxY: maxY === -Infinity ? 0 : maxY,
+ },
+ };
+}
+
+/**
+ * Get thread color from pattern data
+ */
+export function getThreadColor(
+ data: PesPatternData,
+ colorIndex: number,
+): string {
+ if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
+ // Default colors if not specified or index out of bounds
+ const defaultColors = [
+ "#FF0000",
+ "#00FF00",
+ "#0000FF",
+ "#FFFF00",
+ "#FF00FF",
+ "#00FFFF",
+ "#FFA500",
+ "#800080",
+ ];
+ const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
+ return defaultColors[safeIndex];
+ }
+
+ return data.threads[colorIndex]?.hex || "#000000";
+}

Served by rngit 1.3.3 - Generated in 0.07s